Skip to content

Conversation

@harishsundar-okta
Copy link
Contributor

@harishsundar-okta harishsundar-okta commented Jan 7, 2026

Changes

Server-Side Registry Versioning

Implements query parameter-based versioning for shadcn registry components.

Changes

  • Vite Middleware: Intercepts /r/ requests and routes based on ?version= parameter
  • Version Directories: Organized components into v1/ and v2/ folders
  • Latest Alias: ?version=latest maps to newest version (v2)
  • Version Metadata: Added versions.json for version information

Usage

# Default (v1)
npx shadcn@latest add https://ui.auth0.com/r/domain-table.json

# Explicit v1
npx shadcn@latest add "https://ui.auth0.com/r/domain-table.json?version=v1"

# V2 version
npx shadcn@latest add "https://ui.auth0.com/r/domain-table.json?version=v2"

# Latest version
npx shadcn@latest add "https://ui.auth0.com/r/domain-table.json?version=latest"

Once add to public shadcn registry :

npx shadcn@latest add @auth0/domain-table

version will be specified by the user on the components.json file

"registries": {
    "@auth0": {
      "url": "https://auth0-universal-components.vercel.app/r/{name}.json",
      "params": {
        "version": "v1"
      }
    }
}

References

Please include relevant links supporting this change such as a:

  • support ticket
  • community post
  • StackOverflow post
  • support forum thread

Testing

Please describe how this can be tested by reviewers. Be specific about anything not tested and reasons why. If this library has unit and tests should be added for new functionality and existing tests should complete without errors.

  • This change adds unit test coverage
  • This change has been tested on the latest version of the platform/language or why not

Checklist

@harishsundar-okta harishsundar-okta added draft POC Indicates this change is a proof of concept and not production-ready. and removed draft labels Jan 7, 2026
@github-actions
Copy link

github-actions bot commented Jan 7, 2026

🚀 Preview deployment

Branch: refs/pull/49/merge
Commit: de6b682

📝 Preview URL: https://auth0-universal-components-a1gk2q0cc-okta.vercel.app


Updated at 2026-01-08T21:48:56.440Z

@codecov-commenter
Copy link

codecov-commenter commented Jan 7, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 83.17%. Comparing base (dbfb21e) to head (d725e81).

Additional details and impacted files
@@           Coverage Diff            @@
##             main      #49    +/-   ##
========================================
  Coverage   83.17%   83.17%            
========================================
  Files         125      125            
  Lines       10291    10291            
  Branches     1007     1262   +255     
========================================
  Hits         8560     8560            
  Misses       1731     1731            

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.


if (fs.existsSync(versionedPath)) {
try {
const content = fs.readFileSync(versionedPath, 'utf-8');

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Semgrep identified an issue in your code:

User-controlled URL path is used to read files without traversal validation; attackers can escape the intended directory to access arbitrary files like /etc/passwd or .env.

More details about this

The application reads a file from versionedPath without validating that the path stays within the intended public/r/{version} directory. Since fileName is extracted directly from the user-controlled URL (url.pathname.replace(/^\/r\//, '')), an attacker can use path traversal sequences like ../ to escape the directory and access arbitrary files.

Exploit scenario:

  1. Attacker sends a request: GET /r/../../etc/passwd?version=v1
  2. The fileName variable is set to ../../etc/passwd
  3. path.join(process.cwd(), 'public', 'r', 'v1', '../../etc/passwd') resolves to {cwd}/etc/passwd (traversing up and out of the intended directory)
  4. fs.readFileSync(versionedPath) reads the system's /etc/passwd file, exposing sensitive system information
  5. The attacker receives the file contents in the HTTP response

Similarly, an attacker could target ../../.env to leak environment variables containing database credentials or API keys, or ../../config.json to access application secrets.

Dataflow graph
flowchart LR
    classDef invis fill:white, stroke: none
    classDef default fill:#e7f5ff, color:#1c7fd6, stroke: none

    subgraph File0["<b>docs-site/src/api/registry-middleware.ts</b>"]
        direction LR
        %% Source

        subgraph Source
            direction LR

            v0["<a href=https://github.com/auth0/auth0-ui-components/blob/245a83a11e15b5b566c3ab4271461cd2448a848c/docs-site/src/api/registry-middleware.ts#L19 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 19] req.headers</a>"]
        end
        %% Intermediate

        subgraph Traces0[Traces]
            direction TB

            v2["<a href=https://github.com/auth0/auth0-ui-components/blob/245a83a11e15b5b566c3ab4271461cd2448a848c/docs-site/src/api/registry-middleware.ts#L19 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 19] `</a>"]

            v3["<a href=https://github.com/auth0/auth0-ui-components/blob/245a83a11e15b5b566c3ab4271461cd2448a848c/docs-site/src/api/registry-middleware.ts#L19 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 19] url</a>"]

            v4["<a href=https://github.com/auth0/auth0-ui-components/blob/245a83a11e15b5b566c3ab4271461cd2448a848c/docs-site/src/api/registry-middleware.ts#L20 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 20] fileName</a>"]

            v5["<a href=https://github.com/auth0/auth0-ui-components/blob/245a83a11e15b5b566c3ab4271461cd2448a848c/docs-site/src/api/registry-middleware.ts#L33 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 33] versionedPath</a>"]
        end
            v2 --> v3
            v3 --> v4
            v4 --> v5
        %% Sink

        subgraph Sink
            direction LR

            v1["<a href=https://github.com/auth0/auth0-ui-components/blob/245a83a11e15b5b566c3ab4271461cd2448a848c/docs-site/src/api/registry-middleware.ts#L37 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 37] versionedPath</a>"]
        end
    end
    %% Class Assignment
    Source:::invis
    Sink:::invis

    Traces0:::invis
    File0:::invis

    %% Connections

    Source --> Traces0
    Traces0 --> Sink


Loading

To resolve this comment:

✨ Commit Assistant fix suggestion

Suggested change
const content = fs.readFileSync(versionedPath, 'utf-8');
// Validate and normalize the file path to prevent path traversal attacks
const normalizedFileName = path.normalize(fileName).replace(/^(\.\.[\/\\])+/, '');
if (normalizedFileName !== fileName || normalizedFileName.includes('..')) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ error: 'Invalid file path' }));
return;
}
const baseDir = path.resolve(process.cwd(), 'public', 'r', version);
const versionedPath = path.join(baseDir, normalizedFileName);
// Verify the resolved path stays within the base directory
const resolvedPath = path.resolve(versionedPath);
if (!resolvedPath.startsWith(baseDir + path.sep) && resolvedPath !== baseDir) {
res.statusCode = 403;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ error: 'Access denied' }));
return;
}
const content = fs.readFileSync(resolvedPath, 'utf-8');
View step-by-step instructions
  1. Import Node.js's path.normalize() and path.resolve() functions, which are already available in your path dependency.
  2. Add validation to ensure fileName doesn't contain path traversal sequences before constructing the file path. Add this check after extracting fileName:
    const normalizedFileName = path.normalize(fileName).replace(/^(\.\.[\/\\])+/, '');
    if (normalizedFileName !== fileName || normalizedFileName.includes('..')) {
      res.statusCode = 400;
      res.end(JSON.stringify({ error: 'Invalid file path' }));
      return;
    }
  3. Use path.resolve() to get the absolute path of your public directory base: const baseDir = path.resolve(process.cwd(), 'public', 'r', version);
  4. Construct the full file path using the normalized fileName: const versionedPath = path.join(baseDir, normalizedFileName);
  5. Add a security check to verify the resolved path stays within the base directory:
    const resolvedPath = path.resolve(versionedPath);
    if (!resolvedPath.startsWith(baseDir)) {
      res.statusCode = 403;
      res.end(JSON.stringify({ error: 'Access denied' }));
      return;
    }
    This prevents attackers from using sequences like ../../../etc/passwd to access files outside your registry directory.
💬 Ignore this finding

Reply with Semgrep commands to ignore this finding.

  • /fp <comment> for false positive
  • /ar <comment> for acceptable risk
  • /other <comment> for all other reasons

Alternatively, triage in Semgrep AppSec Platform to ignore the finding created by express-fs-filename.

You can view more details about this finding in the Semgrep AppSec Platform.


const versionedPath = path.join(process.cwd(), 'public', 'r', version, fileName);

if (fs.existsSync(versionedPath)) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Semgrep identified an issue in your code:

User-controlled file path from URL allows path traversal to read arbitrary files outside the intended directory.

More details about this

The versionedPath variable is constructed using the fileName parameter extracted from the user's URL, which comes directly from req.url without validation. An attacker can exploit this by crafting a malicious URL to traverse outside the intended public/r/ directory.

Exploit scenario:

  1. Attacker sends a request: /r/../../config.json?version=v1
  2. The code extracts fileName as ../../config.json from the URL pathname
  3. path.join(process.cwd(), 'public', 'r', 'v1', '../../config.json') resolves to process.cwd()/config.json, escaping the intended directory
  4. fs.existsSync(versionedPath) checks this attacker-controlled path
  5. If the file exists, fs.readFileSync(versionedPath) reads sensitive application config files that should not be accessible

Even though the SPECIAL_FILES allowlist exists, it only blocks exact filename matches like index.json. Path traversal sequences like ../, ..\\, or encoded variants bypass this check entirely, allowing attackers to read arbitrary files within the filesystem that the Node.js process has permissions to access.

Dataflow graph
flowchart LR
    classDef invis fill:white, stroke: none
    classDef default fill:#e7f5ff, color:#1c7fd6, stroke: none

    subgraph File0["<b>docs-site/src/api/registry-middleware.ts</b>"]
        direction LR
        %% Source

        subgraph Source
            direction LR

            v0["<a href=https://github.com/auth0/auth0-ui-components/blob/245a83a11e15b5b566c3ab4271461cd2448a848c/docs-site/src/api/registry-middleware.ts#L19 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 19] req.headers</a>"]
        end
        %% Intermediate

        subgraph Traces0[Traces]
            direction TB

            v2["<a href=https://github.com/auth0/auth0-ui-components/blob/245a83a11e15b5b566c3ab4271461cd2448a848c/docs-site/src/api/registry-middleware.ts#L19 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 19] `</a>"]

            v3["<a href=https://github.com/auth0/auth0-ui-components/blob/245a83a11e15b5b566c3ab4271461cd2448a848c/docs-site/src/api/registry-middleware.ts#L19 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 19] url</a>"]

            v4["<a href=https://github.com/auth0/auth0-ui-components/blob/245a83a11e15b5b566c3ab4271461cd2448a848c/docs-site/src/api/registry-middleware.ts#L20 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 20] fileName</a>"]

            v5["<a href=https://github.com/auth0/auth0-ui-components/blob/245a83a11e15b5b566c3ab4271461cd2448a848c/docs-site/src/api/registry-middleware.ts#L33 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 33] versionedPath</a>"]
        end
            v2 --> v3
            v3 --> v4
            v4 --> v5
        %% Sink

        subgraph Sink
            direction LR

            v1["<a href=https://github.com/auth0/auth0-ui-components/blob/245a83a11e15b5b566c3ab4271461cd2448a848c/docs-site/src/api/registry-middleware.ts#L35 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 35] versionedPath</a>"]
        end
    end
    %% Class Assignment
    Source:::invis
    Sink:::invis

    Traces0:::invis
    File0:::invis

    %% Connections

    Source --> Traces0
    Traces0 --> Sink


Loading

To resolve this comment:

✨ Commit Assistant fix suggestion

Suggested change
if (fs.existsSync(versionedPath)) {
// Sanitize and validate the file path to prevent directory traversal attacks
const normalizedFileName = path.normalize(fileName).replace(/^(\.\.(\/|\\|$))+/, '');
const baseDir = path.resolve(process.cwd(), 'public', 'r', version);
const versionedPath = path.resolve(baseDir, normalizedFileName);
// Security check: ensure the resolved path stays within the intended directory
if (!versionedPath.startsWith(baseDir + path.sep) && versionedPath !== baseDir) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ error: 'Invalid file path' }));
return;
}
if (fs.existsSync(versionedPath)) {
View step-by-step instructions
  1. Import Node.js's path.normalize() and path.resolve() functions at the top of the file (these are already available from the imported path module).

  2. After extracting fileName from the URL, validate and sanitize it by normalizing the path and ensuring it doesn't contain directory traversal sequences:

    const fileName = url.pathname.replace(/^\/r\//, '');
    const normalizedFileName = path.normalize(fileName).replace(/^(\.\.(\/|\\|$))+/, '');

    This removes any ../ or ..\ sequences that could allow directory traversal.

  3. Create a safe base directory path using path.resolve() to get the absolute path:

    const baseDir = path.resolve(process.cwd(), 'public', 'r', version);
  4. Build the full file path and resolve it to an absolute path:

    const versionedPath = path.resolve(baseDir, normalizedFileName);
  5. Add a security check to verify the resolved path is still within the intended directory:

    if (!versionedPath.startsWith(baseDir)) {
      res.statusCode = 400;
      res.setHeader('Content-Type', 'application/json');
      res.end(JSON.stringify({ error: 'Invalid file path' }));
      return;
    }

    This ensures that even after path resolution, the file remains within the public/r/version/ directory and prevents access to files outside this directory.

💬 Ignore this finding

Reply with Semgrep commands to ignore this finding.

  • /fp <comment> for false positive
  • /ar <comment> for acceptable risk
  • /other <comment> for all other reasons

Alternatively, triage in Semgrep AppSec Platform to ignore the finding created by express-fs-filename.

You can view more details about this finding in the Semgrep AppSec Platform.

feat: implement component versioning with vercel serverless functions

revert: remove registry versioning menu item from layout

fix: add registry api rewrite to root vercel.json for ci/cd deployments

fix: add functions configuration to locate api directory
@harishsundar-okta harishsundar-okta force-pushed the shadcn-versioning-poc-UIC-40 branch from a25a6b3 to 1c203ba Compare January 8, 2026 19:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

POC Indicates this change is a proof of concept and not production-ready.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants